【iOS】Siriからサクッと注文する機能を作ってみた With App Intents
今年のWWDC24では、「App Intentsが面白くなってぞ!」と感じた方も多いのではないでしょうか?
実はApp Intents自体はWWDC22で発表が行われており、すでに色々な場面で恩恵を受けることができます。
なので、今回はApp Intentsのキャッチアップも兼ねて、iOS 16からでも使える機能を使用して、Siriからサクッと注文ができる機能を作ってみることにしました。
環境
- Xcode 15.2
- iOS 17.5.1
作ったもの: Siriから注文する機能
Siri上で注文を依頼すると、まずは商品の選択肢が表示され、その後、サイズの選択肢が表示されます。注文内容で問題なければ、商品を注文できる機能です。
実装
準備
今回、データベースはSwiftData
を用いて表現します。今回の伝えたい部分ではないので詳細は割愛します。
SwiftData
Model
import Foundation
import SwiftData
@Model
final class OrderItem {
let orderID: UUID
let product: ProductType
let size: Size
let orderDate: Date
init(orderID: UUID = UUID(),
product: ProductType,
size: Size,
orderDate: Date = .now) {
self.orderID = orderID
self.product = product
self.size = size
self.orderDate = orderDate
}
/// 商品とサイズから算出した価格
var price: Int {
let price = product.value.basePrice * size.value.priceMultiplier
return Int(round(price))
}
}
Product
やSize
については後述します。
注文商品を表すモデルで、商品情報やサイズから価格を算出できます。
DataStore
import SwiftData
import Foundation
class OrderDataStore {
private init() {
let schema = Schema([
OrderItem.self,
])
let configuration = ModelConfiguration(schema: schema,
isStoredInMemoryOnly: false)
do {
container = try ModelContainer(for: schema,
configurations: [configuration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}
static let shared = OrderDataStore()
let container: ModelContainer
@MainActor
var context: ModelContext {
return container.mainContext
}
}
また、ID
と一致する注文商品を取得するメソッドと、最新5つまでの注文履歴を取得できるメソッドを事前に用意しておきます。
extension OrderDataStore {
@MainActor
func fetchItem(id: UUID) -> OrderItem? {
let descriptor = FetchDescriptor<OrderItem>(predicate: #Predicate { $0.orderID == id })
if let item = try? context.fetch(descriptor).first {
return item
} else {
return nil
}
}
@MainActor
func fetchRecentFiveOrders() -> [OrderItem] {
var descriptor = FetchDescriptor<OrderItem>(sortBy: [SortDescriptor(\.orderDate, order: .reverse)])
descriptor.fetchLimit = 5
if let items = try? context.fetch(descriptor) {
return items
} else {
return []
}
}
}
AppEnumを作成する
今回はSiriからの応答で選択肢を出す必要がある為、AppEnum
を作成します。
AppEnum
AppEnum
は、ショートカットに開発者が定義した型をショートカットに設定する為に使用します。AppEnum
に準拠するには、StaticDisplayRepresentable
に準拠する必要があり、値の文字列ベースの表現を提供することができます。
AppEnum
に準拠すると、下記のプロパティの追加を求めれます。
// ショートカットで表示されるタイプの名前
static var typeDisplayRepresentation: TypeDisplayRepresentation { get }
// ショートカットで表示されるケースごとのタイトルなどの情報
static var caseDisplayRepresentations: [Self : DisplayRepresentation] { get }
ProductType
ProductType
は商品のタイトルや価格、画像名の情報を持っています。
import AppIntents
enum ProductType: String, Codable {
case hamburger
case beer
var value: Value {
switch self {
case .hamburger:
return Value(displayTitle: "ハンバーガー",
basePrice: 300,
imageName: "hamburger")
case .beer:
return Value(displayTitle: "ビール",
basePrice: 200,
imageName: "beer")
}
}
struct Value {
let displayTitle: LocalizedStringResource
let basePrice: Double
let imageName: String
}
}
AppEnum
にも準拠しており、タイプ名には商品
、ケースの名前をそれぞれ指定しています。
extension ProductType: AppEnum {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "商品"
static var caseDisplayRepresentations: [ProductType: DisplayRepresentation] = [
.hamburger: "ハンバーガー",
.beer: "ビール"
]
}
Size
Size
はサイズ情報、そのサイズにした時の価格を調整する係数を持たせています。
import AppIntents
enum Size: String, Codable {
case small
case medium
case large
var value: Value {
switch self {
case .small:
return Value(displayTitle: "S",
priceMultiplier: 1.0)
case .medium:
return Value(displayTitle: "M",
priceMultiplier: 1.5)
case .large:
return Value(displayTitle: "L",
priceMultiplier: 2.0)
}
}
struct Value {
let displayTitle: LocalizedStringResource
/// サイズによって価格を調整するための係数
let priceMultiplier: Double
}
}
こちらもAppEnum
に準拠しており、各値を指定しています。
extension Size: AppEnum {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "サイズ"
static var caseDisplayRepresentations: [Size: DisplayRepresentation] = [
.small: "S",
.medium: "M",
.large: "L"
]
}
注文用のAppIntentを作成する
AppIntent
Siriやショートカットアプリなどのシステムサービスからユーザーが呼び出すアプリ固有の機能を提供するために使用するインターフェイスです。
OrderIntent
今回は注文用のAppIntent
を作成しました。
import SwiftUI
import AppIntents
struct OrderIntent: AppIntent {
static var title: LocalizedStringResource = "注文する"
@Parameter(title: "商品名", requestValueDialog: "どの商品にしますか?")
var product: ProductType
@Parameter(title: "サイズ", requestValueDialog: "どのサイズですか?")
var size: Size
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let order = OrderItem(product: product, size: size)
try await requestConfirmation(result: .result(dialog: "こちらの注文でよろしいでしょうか?") {
OrderConfirmationView(order: order)
}, confirmationActionName: .order, showPrompt: true)
OrderDataStore.shared.context.insert(order)
return .result(dialog: "注文が完了しました!")
}
static var parameterSummary: some ParameterSummary {
Summary("\(\.$product)を\(\.$size)で注文する")
}
}
title
AppIntent
のタイトルを設定します。
static var title: LocalizedStringResource = "注文する"
@Parameter
@Parameter
を使用することでAppIntent
をカスタムできるようになります。
@Parameter(title: "商品名", requestValueDialog: "どの商品にしますか?")
var product: ProductType
今回、title
とrequestValueDialog
を設定しており、このrequestValueDialog
はユーザーに選択肢を問う際に表示される文言になります。
perform
AppIntent
実行時に行う処理を記述します。
func perform() async throws -> some IntentResult & ProvidesDialog {
let order = OrderItem(product: product, size: size)
try await requestConfirmation(result: .result(dialog: "こちらの注文でよろしいでしょうか?") {
OrderConfirmationView(order: order)
}, confirmationActionName: .order, showPrompt: true)
OrderDataStore.shared.context.insert(order)
return .result(dialog: "注文が完了しました!")
}
流れとしては、下記のようになっています。
- ユーザーが入力した商品、サイズから
OrderItem
を作成 - 入力情報が正しいかユーザーに確認
- 注文履歴にインサート
- 注文完了ダイアログを表示
requestConfirmation
ユーザーからの要求を確認する為に使用するメソッドです。
func requestConfirmation<Result>(
result: Result,
confirmationActionName: ConfirmationActionName = .`continue`,
showPrompt: Bool = true
) async throws where Result : IntentResult
確認で使用するresult
のダイアログで、カスタムViewを使用することができる為、独自で実装したOrderConfirmationView
を使用しています。
confirmationActionName
は、確認画面上で使用する確認ボタンのアクション名を指定することが出来ます。
showPrompt
では、文字列としてのプロンプトを表示するか指定することが出来ます。result
でカスタムViewを使用する際などで内容が重複してしまう場合などはfalse
にして非表示にすることができます。
OrderConfirmationView
requestConfirmation
で使用するカスタムViewも下記のようにSwiftUIで作成したViewを使用することができます。
import SwiftUI
struct OrderConfirmationView: View {
let order: OrderItem
var body: some View {
VStack {
HStack {
Image(order.product.value.imageName)
.resizable()
.scaledToFit()
.frame(width: 120)
VStack(alignment: .leading) {
Text("商品: \(order.product.value.displayTitle)")
Text("サイズ: \(order.size.value.displayTitle)")
}
}
.padding()
Divider()
Text("合計: \(order.price)円")
.frame(maxWidth: .infinity, alignment: .trailing)
.padding()
}
.font(.title3)
}
}
AppShortcutを作成する
AppShortcutsProvider
AppShortcutsProvider
はアプリインストール時にショートカットを作成するためのインタフェースが定義されているプロトコルです。
こちらを使用することでこれまでのようにApp Extensionを追加する必要がなく、簡単にショートカット機能を実装できるようになります。
OrderAppShortcuts
import AppIntents
struct OrderAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: OrderIntent(),
phrases: ["\(.applicationName)でオーダーする",
"\(.applicationName)でオーダー",
"\(.applicationName)で注文する",
"\(.applicationName)で注文"],
shortTitle: "フードを注文する",
systemImageName: "takeoutbag.and.cup.and.straw.fill"
)
}
static var shortcutTileColor: ShortcutTileColor = .grayGreen
}
AppShortcut
のintent
には、ショートカット実行時に呼び出したいAppIntent
を設定します。
pharase
には、Siri呼び出しの際に反応する為の文言を指定します。さまざまなユーザーの言い回しに対応できるようにする為に考えられる限りのフレーズを追加する必要があるように思えました。
shortTitle
とsystemImageName
はショートカットとして表示される際のタイトルとアイコンイメージになります。
ここまでの実装で、Siriから注文する機能が表現できるようになりました。
複数選択肢がある場合のデメリット
今回は一品ずつしか注文できないですが、これがもし複数注文できた場合、そのたびに商品やサイズの選択を確認する状態では、ユーザーはやや鬱陶しいと感じて途中で注文するのを諦めてしまうかもしれません。
なので、一度注文した履歴の中から注文できる機能の実装にもチャレンジしてみることにしました。
作ったもの: Siriで注文履歴から再注文できる機能
Siri上で再注文を依頼すると注文履歴が表示され、その中から再注文した商品を選択。注文内容で問題なければ、商品を注文できる機能です。
AppEntity
Siriやショートカットなどにアプリが提供する特定のアクションやデータを公開するためのインターフェイスです。
ReorderItem
注文履歴用のモデルです。
import AppIntents
struct ReorderItem: Identifiable {
let id: UUID
let product: ProductType
let size: Size
let orderDate: Date
init(orderItem: OrderItem) {
self.id = orderItem.orderID
self.product = orderItem.product
self.size = orderItem.size
self.orderDate = orderItem.orderDate
}
}
extension ReorderItem: AppEntity {
static var typeDisplayRepresentation: TypeDisplayRepresentation {
return "再注文商品"
}
var displayRepresentation: DisplayRepresentation {
let title: LocalizedStringResource = "\(product.value.displayTitle)の\(size.value.displayTitle)サイズ"
return DisplayRepresentation(title: title)
}
static var defaultQuery = ReorderItemQuery()
}
displayRepresentation
はこのAppEntity
を表示する際の値を記述します。
またAppEntity
はdefaultQuery
が必須パラメータになっており、アプリ内で使用しているデータを取得するために使用します。
ReorderItemQuery
defaultQuery
はEntityQuery
に準拠している必要があります。
EntityQuery
は識別子を使用してエンティティをクエリする為のインターフェースです。準拠する為に下記二つのメソッドの記述を求められます。
// MARK: - EntityQuery for ReorderItem
struct ReorderItemQuery: EntityQuery {
// 識別子からエンティティを取得する
func entities(for identifiers: [UUID]) async throws -> [ReorderItem] {
var items: [ReorderItem] = []
for i in 0..<identifiers.count {
if let item = await OrderDataStore.shared.fetchItem(id: identifiers[i]) {
items.append(ReorderItem(orderItem: item))
}
}
return items
}
// このクエリによってサポートされるオプションのリストが提示されたときに表示される初期結果を返す
func suggestedEntities() async throws -> [ReorderItem] {
// 最近の5つまでの注文情報を返す
let items = await OrderDataStore.shared.fetchRecentFiveOrders()
return items.compactMap({ ReorderItem(orderItem: $0) })
}
}
再注文用のAppIntentを作成する
ReorderIntent
import AppIntents
import SwiftUI
struct ReorderIntent: AppIntent {
static var title: LocalizedStringResource = "注文履歴から注文する"
@Parameter(title: "注文履歴", requestValueDialog: "どちらを再注文しますか?")
var item: ReorderItem?
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let reorderItem = try await $item.requestDisambiguation(
among: OrderDataStore.shared.fetchRecentFiveOrders().map({ ReorderItem(orderItem: $0) }),
dialog: IntentDialog("どちらの商品を再注文しますか?")
)
let order = OrderItem(product: reorderItem.product, size: reorderItem.size)
try await requestConfirmation(result: .result(dialog: "こちらの注文でよろしいでしょうか?") {
OrderConfirmationView(order: order)
}, confirmationActionName: .order, showPrompt: true)
OrderDataStore.shared.context.insert(order)
return .result(dialog: "注文が完了しました!")
}
static var parameterSummary: some ParameterSummary {
Summary("\(\.$item)を再注文する")
}
}
注文時のOrderIntent
と変わっている点としては、var item: ReorderItem?
と入力される値がオプショナルになっています。
注文情報がオプショナルとなっている為、perform
内で入力情報の曖昧さを回避する為に、requestDisambiguation
を実行して該当する値を取得します。
WWDC22のセッション: Design App Shortcutsの中でも5 個以下の値のリストに最適であることに留意してくださいとあったので、直近の5つの注文情報を表示して、その中から再注文する商品を選ぶことにしました。
それ以降の流れについては、OrderIntent
と同様に確認ダイアログを表示して、注文という流れになっています。
AppShortcutを追加する
今回、ReorderIntent
も実行するSiri、ショートカット経由で実行する必要があるのでOrderAppShortcuts
に追加します。
import AppIntents
struct OrderAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: OrderIntent(),
phrases: ["\(.applicationName)でオーダーする",
"\(.applicationName)でオーダー",
"\(.applicationName)で注文する",
"\(.applicationName)で注文"],
shortTitle: "フードを注文する",
systemImageName: "takeoutbag.and.cup.and.straw.fill"
)
+ AppShortcut(
+ intent: ReorderIntent(),
+ phrases: ["\(.applicationName)で注文履歴からオーダーする",
+ "\(.applicationName)で注文履歴からオーダー",
+ "\(.applicationName)で注文履歴から注文する",
+ "\(.applicationName)で注文履歴から注文"],
+ shortTitle: "フードを注文履歴から注文する",
+ systemImageName: "takeoutbag.and.cup.and.straw.fill"
)
}
static var shortcutTileColor: ShortcutTileColor = .grayGreen
}
以上でSiriからサクッと注文、再注文ができるようになりました。
コード
GitHubに置いております。
終わりに
AppShortcut
のphrase
については、ちゃんと認識してくれなかったりするので、何パターンも用意したり、良いフレーズを慎重に検討する必要はあるかもしれません。
また、アプリで作成できるショートカットは最大10個です。アプリにフォーカスする必要がなくても自己完結できる機能だけに絞り、最適なショートカット機能を作成しましょう!
参考